前言

阅读此教程需要一定的C++和JUCE基础,如果没有可以翻阅我的其他文章和视频教程。
核心内容为UI设计,Slider样式的高阶设计技巧。
Github原地址:https://github.com/szkkng/ModernDial
我的Bilibili频道:香芋派Taro
我的公众号:香芋派的烘焙坊
我的音频技术交流群:1136403177
我的个人微信:JazzyTaroPie

准备

打开Projucer并创建一个新项目。因为这篇教程只涉及到UI部分,所以我们选择”GUI”模版即可。

确保这些文件都在Source目录下:

至此所有的准备都已完成,让我们开始吧!

旋钮

在这个章节中,我们会设计这个旋钮的基础部分。

自定义Slider

首先,让我们准备一个从Slider class继承的Dial class,同时override一些函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#pragma once

#include <JuceHeader.h>

class Dial : public juce::Slider
{
public:
Dial();
~Dial();

void mouseDown (const juce::MouseEvent& event) override;
void mouseUp (const juce::MouseEvent& event) override;

private:
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (Dial)
};
在下方的构造函数中,许多函数被调用了,但我只会解释其中的一部分。
Dial::Dial()
{
setSliderStyle (juce::Slider::SliderStyle::RotaryVerticalDrag);
setTextBoxStyle (juce::Slider::TextBoxBelow, true, 80, 20);
setRotaryParameters (juce::MathConstants<float>::pi * 1.25f,
juce::MathConstants<float>::pi * 2.75f,
true);
setVelocityBasedMode (true);
setVelocityModeParameters (0.5, 1, 0.09, false);
setRange (0.0, 100.0, 0.01);
setValue (50.0);
setDoubleClickReturnValue (true, 50.0);
setTextValueSuffix (" %");
onValueChange = [&]()
{
if (getValue() < 10)
setNumDecimalPlacesToDisplay (2);
else if (10 <= getValue() && getValue() < 100)
setNumDecimalPlacesToDisplay (1);
else
setNumDecimalPlacesToDisplay (0);
};
}

Dial::~Dial()
{
}

如果你对其中setRotaryParameters()这个函数比较疑惑的话,可以点击链接查看以下这篇文章,十分有用。
https://theaudioprogrammer.com/customizing-audio-plug-in-interfaces-with-juce-pt-2-creating-an-ableton-style-dial/

setVelocityBasedMode()

如果 setVelocityBasedMode() 被设置为true,不止是鼠标在被拖拽时会消失,同时还会根据鼠标的移动速度来更改值变化的速度,更加符合直觉。为了能更清晰地看清两者的区别,这里附上关闭和打开的对比图片。

False

True

由于其中有很多感知元素,这里建议自己尝试去修改ture和false来体验一下二者区别。

onValueChange

其中还有一个lambda表达式叫做 onValueChange ,当slider的值变化的时候会被执行。这个函数确保了slider在任何值时其数值都会被正常显示,具体差别如下:

Before

After

正如你看到的那样,因为数字的宽度并没有发生太大的改变,所以他看上去很美观。

mouseDown() mouseUp()

接下来,我会解释 mouseDown()mouseUp() 两个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Dial::mouseDown (const juce::MouseEvent& event)
{
juce::Slider::mouseDown (event);

setMouseCursor (juce::MouseCursor::NoCursor);
}

void Dial::mouseUp (const juce::MouseEvent& event)
{
juce::Slider::mouseUp (event);

juce::Desktop::getInstance().getMainMouseSource().setScreenPosition (event.source.getLastMouseDownPosition());

setMouseCursor (juce::MouseCursor::NormalCursor);
}

你可能会好奇为什么我还要再设计一个处理来在velocity模式被打开时隐藏鼠标,因为我想让鼠标在点击的瞬间被隐藏。如果仅仅只打开这个模式,那么在你的鼠标点击时光标不会被隐藏,而是在你开始拖动时才会被隐藏。
mouseUp() 中的函数确保了当鼠标释放时,光标将会回到点击瞬间时的位置。这可能看上去没有太大的差别,但这个小细节就提升了用户的使用体验。

创建旋钮对象

让我们引入头文件并开始准备三个旋钮对象。首先我们先定义他们的颜色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#pragma once

#include <JuceHeader.h>
#include "Dial.h"

class MainComponent : public juce::Component
{
public:
//==============================================================================
MainComponent();
~MainComponent();

//==============================================================================
void paint (juce::Graphics&) override;
void resized() override;

private:
//==============================================================================
Dial blueDial, yellowDial, greenDial;

juce::Colour blue = juce::Colour::fromFloatRGBA (0.43f, 0.83f, 1.0f, 1.0f);
juce::Colour green = juce::Colour::fromFloatRGBA (0.34f, 0.74f, 0.66f, 1.0f);
juce::Colour yellow = juce::Colour::fromFloatRGBA (1.0f, 0.71f, 0.2f, 1.0f);
juce::Colour black = juce::Colour::fromFloatRGBA (0.08f, 0.08f, 0.08f, 1.0f);

JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainComponent)
};

定义的部分如下,因为并不难,所以我会跳过这些内容的解释。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include "MainComponent.h"

//==============================================================================
MainComponent::MainComponent()
{
setSize (600, 400);

blueDial.setColour (juce::Slider::rotarySliderFillColourId, blue);
greenDial.setColour (juce::Slider::rotarySliderFillColourId, green);
yellowDial.setColour (juce::Slider::rotarySliderFillColourId, yellow);

addAndMakeVisible (blueDial);
addAndMakeVisible (greenDial);
addAndMakeVisible (yellowDial);
}

MainComponent::~MainComponent()
{
}

//==============================================================================
void MainComponent::paint (juce::Graphics& g)
{
g.fillAll (black) ;
}

void MainComponent::resized()
{
blueDial.setBounds (120, 160, 80, 80);
greenDial.setBounds (260, 160, 80, 80);
yellowDial.setBounds (400, 160, 80, 80);
}

运行

现在旋钮的基础已经完成,让我们试着运行一下吧!

LookAndFeel

在这个章节中,我们会自定义LookAndFeel类来继续完善这个旋钮。

自定义LookAndFeel

头文件的内容如下,我们根据slider的描述override三个成员函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#pragma once

#include <JuceHeader.h>

class CustomLookAndFeel : public juce::LookAndFeel_V4
{
public:
CustomLookAndFeel();
~CustomLookAndFeel();

juce::Slider::SliderLayout getSliderLayout (juce::Slider& slider) override;

void drawRotarySlider (juce::Graphics&, int x, int y, int width, int height,
float sliderPosProportional, float rotaryStartAngle,
float rotaryEndAngle, juce::Slider&) override;

juce::Label* createSliderTextBox (juce::Slider& slider) override;

private:
juce::Colour blue = juce::Colour::fromFloatRGBA (0.43f, 0.83f, 1.0f, 1.0f);
juce::Colour offWhite = juce::Colour::fromFloatRGBA (0.83f, 0.84f, 0.9f, 1.0f);
juce::Colour grey = juce::Colour::fromFloatRGBA (0.42f, 0.42f, 0.42f, 1.0f);
juce::Colour blackGrey = juce::Colour::fromFloatRGBA (0.2f, 0.2f, 0.2f, 1.0f);

JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (CustomLookAndFeel);
};

getSliderLayout()

getSliderLayout() 函数定义了旋钮的位置、大小和中央的文本框。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include "CustomLookAndFeel.h"

CustomLookAndFeel::CustomLookAndFeel() {};
CustomLookAndFeel::~CustomLookAndFeel() {};

juce::Slider::SliderLayout CustomLookAndFeel::getSliderLayout (juce::Slider& slider)
{
auto localBounds = slider.getLocalBounds();

juce::Slider::SliderLayout layout;

layout.textBoxBounds = localBounds;
layout.sliderBounds = localBounds;

return layout;
}

drawRotarySlider()

drawRotarySlider() 函数对这个旋钮的影响很大。他确保了在旋钮被缩放时不会变形,绘制了旋钮背后正方形的四个角,同时让旋钮的角度与数值相关联。
现在让我们override createsSliderTextBox()这个函数,它的功能是使旋钮中央的文本框显示当前旋钮的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
juce::Label* CustomLookAndFeel::createSliderTextBox (juce::Slider& slider)
{
auto* l = new juce::Label();

l->setFont (17.0f);
l->setJustificationType (juce::Justification::centred);
l->setColour (juce::Label::textColourId, slider.findColour (juce::Slider::textBoxTextColourId));
l->setColour (juce::Label::textWhenEditingColourId, slider.findColour (juce::Slider::textBoxTextColourId));
l->setColour (juce::Label::outlineWhenEditingColourId, slider.findColour (juce::Slider::textBoxOutlineColourId));
l->setInterceptsMouseClicks (false, false);

return l;
}

setInterceptMouseClicks()

setInterceptMouseClicks() 是一个非常重要的函数,如果这个函数没有被设定为false,那么你将无法在文本框上拖拽,如果此时文本框在旋钮中央的话,这会是一个致命的问题。

True

False

创建CustomLookAndFeel对象

CustomLookAndFeel将会被应用至旋钮对象,所以在Dial.h中引入这个头文件来准备创建对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#pragma once

#include <JuceHeader.h>
#include "CustomLookAndFeel.h"

class Dial : public juce::Slider
{
public:
・・・
private:
CustomLookAndFeel customLookAndFeel;

juce::Colour grey = juce::Colour::fromFloatRGBA (0.42f, 0.42f, 0.42f, 1.0f);
juce::Colour blackGrey = juce::Colour::fromFloatRGBA (0.2f, 0.2f, 0.2f, 1.0f);
・・・

为了将LookAndFeel应用至旋钮对象,需要调用setLookAndFeel()函数。同时,设定好文本框的颜色并为接下来的focus mark做好准备(focus mark就是把鼠标移到旋钮上四周会出现的那个矩形框)。

1
2
3
4
5
6
7
Dial::Dial()
{
・・・
setColour (juce::Slider::textBoxTextColourId, blackGrey);
setColour (juce::Slider::textBoxOutlineColourId, grey);
setLookAndFeel (&customLookAndFeel);
}

运行

Focus Mark

在这个章节中,我们会设计Focus Mark。

Overriding paint()

override paint() 来让它描述这个标记

1
2
3
4
5
6
7
8
9
10
11
12
13
class Dial  : public juce::Slider
{
public:
Dial();
~Dial();

void paint (juce::Graphics& g) override;

void mouseDown (const juce::MouseEvent& event) override;
void mouseUp (const juce::MouseEvent& event) override;

private:
・・・

定义部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void Dial::paint (juce::Graphics& g)
{
juce::Slider::paint (g);

if (hasKeyboardFocus (false))
{
auto bounds = getLocalBounds().toFloat();
auto h = bounds.getHeight();
auto w = bounds.getWidth();
auto len = juce::jmin (h, w) * 0.07f;
auto thick = len / 1.8f;

g.setColour (findColour (juce::Slider::textBoxOutlineColourId));

// Left top
g.drawLine (0.0f, 0.0f, 0.0f, len, thick);
g.drawLine (0.0f, 0.0f, len, 0.0f, thick);

// Left bottom
g.drawLine (0.0f, h, 0.0f, h - len, thick);
g.drawLine (0.0f, h, len, h, thick);

// Right top
g.drawLine (w, 0.0f, w, len, thick);
g.drawLine (w, 0.0f, w - len, 0.0f, thick);

// Right bottom
g.drawLine (w, h, w, h - len, thick);
g.drawLine (w, h, w - len, h, thick);
}
}

当鼠标被移动到旋钮上时,hasKeyboardFocus() 会返回true。
此外,在focus时下面这个函数必须被调用。

1
2
3
4
5
Dial::Dial()
{
・・・
setWantsKeyboardFocus (true);
}

现在尝试点击和拖拽每一个旋钮,当鼠标点击到不同的旋钮时,周围的focus mark也会相应变化。

最后,因为此时除非点击其他旋钮,否则focus mark不会消失。为了解决这个问题,setWantsKetboardFocus()必须在下面这个构造函数中被调用。

1
2
3
4
5
6
MainComponent::MainComponent()
{
setSize (600, 400);
setWantsKeyboardFocus (true);
・・・
}

总结

以上就是所有的关于这个旋钮的设计,最终效果如下:
![](164s mark也会同步消失。
在这篇教程中,我们讲解了如何去设计一个modern dial。如果你有任何改进和建议,欢迎评论和交流!感谢您能阅读到这里!